Skip to content

Public snapshot codec and snapshot::write_file for a standalone store (#1907) - #1922

Merged
Kalyanpulikanti-01 merged 2 commits into
devfrom
feat/1907-snapshot-codec
Oct 19, 2026
Merged

Kalyanpulikanti-01 merged 2 commits into
devfrom
feat/1907-snapshot-codec

Conversation

@nareshkolimi

Copy link
Copy Markdown
Collaborator

Fixes #1907. Part of #1905.

samyama convert (#1905) builds a graph in process and writes it as .sgsnap without a server. This adds the two pieces the issue lists as missing.

1. snapshot::codec is public

property_to_json and json_to_property define the {"__type": "Date", "days": …} encoding for every value JSON has no type for. They were private in src/snapshot/mod.rs, so any other reader or writer had to copy them, and a copy drifts.

  • They have moved, unchanged, to src/snapshot/codec.rs and are pub. mod.rs imports them, so every existing caller and test is untouched.
  • docs/ADR/ADR-022-snapshot-format.md gains a "Typed values" section. It lists all 8 tags (DateTime, Date, LocalTime, Time, LocalDateTime, ZonedDateTime, Duration, Vector) with their fields, units and meaning. It also gives the reading rules a second implementation needs to agree with this one:
    • a missing required field is read as a plain map, not defaulted, except Duration;
    • an unknown tag is a plain map;
    • strings are trimmed;
    • one ambiguity is inherent: a user map shaped exactly like a typed value reads back as that value.

2. snapshot::write_file

pub fn write_file(store: &GraphStore, path: impl AsRef<Path>, opts: &WriteOptions)
    -> Result<ExportStats, Box<dyn Error>>
  • It creates parent directories.
  • The file appears whole or not at all. It writes <path>.tmp, flushes and fsyncs it, then renames it over path. On Unix it also syncs the directory so the rename is durable. A failed export removes the temporary file and leaves any previous path intact.
  • It returns the export's own ExportStats, dropped included.
  • WriteOptions { compression_level } defaults to DEFAULT_SNAPSHOT_COMPRESSION.

Tests (the issue's acceptance criteria)

  • Codec, every variant (src/snapshot/codec.rs): one value of each of the 15 PropertyValue variants round-trips through serde_json::Value and through serialized text. The value list sits beside an exhaustive match with no wildcard, so adding a variant to PropertyValue won't compile until it has a test value.
  • write_file → import_tenant (tests/snapshot_write_file.rs):
    • a store with 3 labels, 2 edge types, typed node properties (Date, Float, list, Vector, Boolean, Integer) and a Duration edge property is written into a nested directory that doesn't exist yet, imported into a fresh store, and compared node by node and edge by edge, keyed by uid because import remaps ids;
    • no .tmp is left beside the file;
    • an existing non-snapshot file is replaced whole;
    • a write whose parent path is a file errors and leaves nothing behind.
  • ADR-022 lists the __type tags.

cargo check --workspace --tests --examples --benches is clean, and the lint ratchet is under its ceiling.

Not in scope

As the issue says, there is no streaming writer (records written while the input is still being read, with no GraphStore). That gets a separate issue once an input doesn't fit in memory.

…#1907)

- snapshot::codec: property_to_json / json_to_property move out of
  snapshot/mod.rs and become pub, so a converter writing or reading .sgsnap
  (samyama convert, #1905) calls them instead of copying the __type encoding.
  The bodies are unchanged.
- snapshot::write_file(store, path, &WriteOptions) -> ExportStats: creates
  parent directories, writes <path>.tmp, syncs it and renames it over path,
  so a failed or interrupted export never leaves a truncated .sgsnap; removes
  the temporary file on error; returns the export's own stats, dropped
  included.
- ADR-022 gains "Typed values" (every __type tag, its fields and the reading
  rules) and "Writing a file from a standalone store".

Tests: codec round-trips one value of every PropertyValue variant, through
serde_json::Value and through text, with an exhaustive match so a new variant
fails to compile until it is covered. tests/snapshot_write_file.rs writes a
graph with 3 labels, 2 edge types, typed node properties and an edge
property, imports it into a fresh store and compares node by node and edge by
edge; checks an existing file is replaced whole and that a write that cannot
happen errors and leaves nothing behind.

@Kalyanpulikanti-01 Kalyanpulikanti-01 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. Two details here are better than they needed to be.

The exhaustiveness guard actually works. one_of_each() ends in a match over the values with no wildcard and all 15 arms, so adding a variant to PropertyValue fails to compile until it has a test value — and therefore a round trip through the codec. That is the right mechanism for this specific risk: a codec whose test list is maintained by hand drifts silently the moment someone adds a type, and the symptom is a property that survives in memory and vanishes through a snapshot. Paired with assert_eq!(values.len(), 15) it also catches the lazier failure of deleting one entry and duplicating another.

the_round_trip_survives_serialization_to_text is the test most such codecs omit, and the comment says exactly why it matters: a value that round-trips as a serde_json::Value but not through text is still lost, because text is what a .sgsnap actually contains. Float formatting and large integers are the usual casualties.

The atomic write is done properly. Temp file → flush → fsync → rename, plus a directory sync on Unix so the rename itself is durable, and the temp file removed on failure with any previous path intact. The directory sync is the step normally skipped, and skipping it means the rename can be lost on power failure while the data file is intact — a half-committed export that looks complete. The test asserting no .tmp is left beside the file, and that a write whose parent path is a file errors leaving nothing behind, covers both directions.

Making the codec public rather than letting samyama convert copy it is the whole point of the issue, and moving property_to_json/json_to_property unchanged so mod.rs re-imports them keeps the blast radius at zero. The ADR-022 "Typed values" section is what makes a second implementation possible: listing the 8 tags with units is table stakes, but the reading rules — missing required field reads as a plain map except Duration, unknown tag reads as a plain map, strings trimmed — are the part a reimplementor would otherwise have to infer from behaviour. Naming the inherent ambiguity (a user map shaped like a typed value reads back as that value) rather than pretending the format is unambiguous is the honest thing to document; it's a real wart and now a known one.

Comparing by uid rather than id in tests/snapshot_write_file.rs because import remaps ids is correct and easy to get wrong.

No requests. One idle observation: write_file returns ExportStats with dropped included, which means a caller can discard a lossy export silently — the same shape I raised on #1921's RdfImportReport. If that type picks up #[must_use], ExportStats probably wants it for the same reason, and that's a single follow-up across the three report types rather than anything for this PR.

@Kalyanpulikanti-01
Kalyanpulikanti-01 merged commit 0019f68 into dev Oct 19, 2026
12 checks passed
@Kalyanpulikanti-01
Kalyanpulikanti-01 deleted the feat/1907-snapshot-codec branch October 19, 2026 18:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants